Оглавление

  • Исследование продаж продуктов питания через мобильное приложение
    • Откроем файл с данными и изучим общую информацию
      • Импортируем библиотеки и настроим Python
      • Считаем данные из csv-файла в датафрейм и сохраняем в переменную data
      • Получим общую информацию о датафрейме с помощью метода .info()
    • Подготовим данные для анализа
      • Заменим названия столбцов на более удобные
      • Представим столбец event_time в формате datetime
      • Добавим новый столбец с датами - date
      • Проверим наличие дублирующихся строк
      • Проверим, не попали ли одни и те же пользователи в разные группы
    • Изучим и проверим данные
      • Узнаем, сколько всего событий в логе и сколько пользователей
      • Изучим временной период данных
      • Отбросим данные, старше 2019-08-01
      • Проверим, что у нас есть пользователи из всех трёх экспериментальных групп
    • Изучим воронку событий
      • Посмотрим, какие события есть в логах, как часто они встречаются. Отсортируем события по частоте.
      • Посчитаем, сколько пользователей совершали каждое из этих событий. Отсортируем события по числу пользователей. Посчитаем долю пользователей, которые хоть раз совершали событие.
      • По воронке событий посчитаем, какая доля пользователей проходит на следующий шаг воронки (от числа пользователей на предыдущем).
    • Изучим результаты эксперимента
      • Посмотрим, сколько пользователей в каждой экспериментальной группе
      • Проверим, находят ли статистические критерии разницу между выборками 246 и 247 (А/А-эксперимент)
      • Проверим, есть ли статистические разницы между контрольными группами и группой с изменённым шрифтом. Сравним результаты с каждой из контрольных групп в отдельности по каждому событию. Сравним результаты с объединённой контрольной группой.
    • Общий вывод

Исследование продаж продуктов питания через мобильное приложение¶

Перед нами стартап, который продаёт продукты питания. Нужно разобраться, как ведут себя пользователи мобильного приложения.

Необходимо изучить воронку продаж. Узнать, как пользователи доходят до покупки. Сколько пользователей доходит до покупки, а сколько — «застревает» на предыдущих шагах? На каких именно?

После этого необходимо исследовать результаты A/A/B-эксперимента. Дизайнеры захотели поменять шрифты во всём приложении, а менеджеры испугались, что пользователям будет непривычно. Договорились принять решение по результатам A/A/B-теста. Пользователей разбили на 3 группы: 2 контрольные со старыми шрифтами и одну экспериментальную — с новыми. Необходимо выяснить, какой шрифт лучше.

Описание данных

Каждая запись в логе — это действие пользователя, или событие.

  • EventName — название события;
  • DeviceIDHash — уникальный идентификатор пользователя;
  • EventTimestamp — время события;
  • ExpId — номер эксперимента: 246 и 247 — контрольные группы, а 248 — экспериментальная.

Откроем файл с данными и изучим общую информацию¶

Импортируем библиотеки и настроим Python¶

In [1]:
# импортируем библиотеки
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import scipy.stats as stats
import seaborn as sns
import datetime as dt
from datetime import date
import plotly.express as px
In [2]:
# убираем предупреждения
pd.options.mode.chained_assignment = None  # default='warn'
from matplotlib.axes._axes import _log as matplotlib_axes_logger
matplotlib_axes_logger.setLevel('ERROR')

# увеличим максимальное количество отображающихся столбцов
pd.set_option('display.max_columns', None)

# увеличим максимальную ширину отображающихся столбцов
pd.set_option('max_colwidth', 120)

Считаем данные из csv-файла в датафрейм и сохраняем в переменную data¶

In [3]:
# считаем данные
try:
    data = pd.read_csv('/datasets/logs_exp.csv', sep='\t')
except:
    data = pd.read_csv('logs_exp.csv', sep='\t')
data
Out[3]:
EventName DeviceIDHash EventTimestamp ExpId
0 MainScreenAppear 4575588528974610257 1564029816 246
1 MainScreenAppear 7416695313311560658 1564053102 246
2 PaymentScreenSuccessful 3518123091307005509 1564054127 248
3 CartScreenAppear 3518123091307005509 1564054127 248
4 PaymentScreenSuccessful 6217807653094995999 1564055322 248
... ... ... ... ...
244121 MainScreenAppear 4599628364049201812 1565212345 247
244122 MainScreenAppear 5849806612437486590 1565212439 246
244123 MainScreenAppear 5746969938801999050 1565212483 246
244124 MainScreenAppear 5746969938801999050 1565212498 246
244125 OffersScreenAppear 5746969938801999050 1565212517 246

244126 rows × 4 columns

Получим общую информацию о датафрейме с помощью метода .info()¶

In [4]:
data.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 244126 entries, 0 to 244125
Data columns (total 4 columns):
 #   Column          Non-Null Count   Dtype 
---  ------          --------------   ----- 
 0   EventName       244126 non-null  object
 1   DeviceIDHash    244126 non-null  int64 
 2   EventTimestamp  244126 non-null  int64 
 3   ExpId           244126 non-null  int64 
dtypes: int64(3), object(1)
memory usage: 7.5+ MB

Можем заметить, что:

  • названия столбцов записаны не на "змеином" регистре
  • в данных отсутствуют пропуски
  • время не представлено в формате datetime

Всего в датафрейме 244126 наблюдений.

Подготовим данные для анализа¶

Заменим названия столбцов на более удобные¶

In [5]:
data.columns = ['event_name', 'user_id', 'event_time', 'exp_id']
data.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 244126 entries, 0 to 244125
Data columns (total 4 columns):
 #   Column      Non-Null Count   Dtype 
---  ------      --------------   ----- 
 0   event_name  244126 non-null  object
 1   user_id     244126 non-null  int64 
 2   event_time  244126 non-null  int64 
 3   exp_id      244126 non-null  int64 
dtypes: int64(3), object(1)
memory usage: 7.5+ MB

Представим столбец event_time в формате datetime¶

In [6]:
data['event_time'] = pd.to_datetime(data['event_time'], unit='s')

Добавим новый столбец с датами - date¶

In [7]:
data['date'] = data['event_time'].dt.date

Проверим наличие дублирующихся строк¶

In [8]:
data[data.duplicated()]
Out[8]:
event_name user_id event_time exp_id date
453 MainScreenAppear 5613408041324010552 2019-07-30 08:19:44 248 2019-07-30
2350 CartScreenAppear 1694940645335807244 2019-07-31 21:51:39 248 2019-07-31
3573 MainScreenAppear 434103746454591587 2019-08-01 02:59:37 248 2019-08-01
4076 MainScreenAppear 3761373764179762633 2019-08-01 03:47:46 247 2019-08-01
4803 MainScreenAppear 2835328739789306622 2019-08-01 04:44:01 248 2019-08-01
... ... ... ... ... ...
242329 MainScreenAppear 8870358373313968633 2019-08-07 19:26:44 247 2019-08-07
242332 PaymentScreenSuccessful 4718002964983105693 2019-08-07 19:26:45 247 2019-08-07
242360 PaymentScreenSuccessful 2382591782303281935 2019-08-07 19:27:29 246 2019-08-07
242362 CartScreenAppear 2382591782303281935 2019-08-07 19:27:29 246 2019-08-07
242635 MainScreenAppear 4097782667445790512 2019-08-07 19:36:58 246 2019-08-07

413 rows × 5 columns

Выявлено 413 дубликатов, удалим их.

In [9]:
data = data.drop_duplicates().reset_index()

Проверим, не попали ли одни и те же пользователи в разные группы¶

In [10]:
# используем метод groupby и функцию nunique для определения количества групп, в которых участвовал каждый пользователь
data_grouped = data.groupby('user_id').agg({'exp_id': 'nunique'}).sort_values('exp_id', ascending=False).reset_index()

# посчитаем количество пользователей, которые участвовали в двух группах
print('Количество пользователей равно:', len(data_grouped))
print('Количество пользователей, которые участвовали в двух и более группах равно:', len(data_grouped.query('exp_id==2 or exp_id==3')))
Количество пользователей равно: 7551
Количество пользователей, которые участвовали в двух и более группах равно: 0

Вывод

Отсутствуют пользователи, которые участвовали в нескольких экспериментальных группах. Следовательно, разграничение пользователей между группами в эксперименте сконструировано правильно.

Изучим и проверим данные¶

Узнаем, сколько всего событий в логе и сколько пользователей¶

In [11]:
print('Уникальные события:', data['event_name'].unique())
print()
print('Количество уникальных событий:', len(data['event_name'].unique()))
Уникальные события: ['MainScreenAppear' 'PaymentScreenSuccessful' 'CartScreenAppear'
 'OffersScreenAppear' 'Tutorial']

Количество уникальных событий: 5
In [12]:
print('Уникальные пользователи:', data['user_id'].unique())
print()
print('Количество уникальных пользователей:', len(data['user_id'].unique()))
Уникальные пользователи: [4575588528974610257 7416695313311560658 3518123091307005509 ...
 6660805781687343085 7823752606740475984 3454683894921357834]

Количество уникальных пользователей: 7551
In [13]:
print('На пользователя в среднем приходится событий:', round(data.groupby('user_id').count()['event_name'].mean(), 2))
На пользователя в среднем приходится событий: 32.28

Изучим временной период данных¶

In [14]:
print('Минимальная дата:', data['date'].min())
print('Максимальная дата:', data['date'].max())
print('Мы располагаем данными за период:', data['date'].max().toordinal()-data['date'].min().toordinal() + 1, 'дней')
Минимальная дата: 2019-07-25
Максимальная дата: 2019-08-07
Мы располагаем данными за период: 14 дней

Построим гистограмму по дате-времени.

In [15]:
data['event_time'].hist(bins=(data['date'].max().toordinal()-data['date'].min().toordinal() + 1)*24, figsize=(13, 5))
plt.xticks(rotation=45)
plt.title('График распределения событий по дате-времени')
plt.xlabel('Дата')
plt.ylabel('Количество событий');

Вывод

Мы не можем быть уверены, что у вас одинаково полные данные за весь период. Технически в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого — это может «перекашивать данные». По гистограмме можем увидеть, что наши данные становятся полными начиная с 2019-08-01. Скорее всего, временной лаг составляет одну неделю.

Отбросим данные, старше 2019-08-01¶

In [16]:
# сохраним первоначальные данные в переменной data_old
data_old = data

# удаляем даты до '2019-08-01'
data = data.query("event_time >= '2019-08-01'")

# заново строим гистограмму для проверки
data['event_time'].hist(bins=(data['date'].max().toordinal()-data['date'].min().toordinal() + 1)*24, figsize=(13, 5))
plt.xticks(rotation=45)
plt.title('График распределения событий по дате-времени')
plt.xlabel('Дата')
plt.ylabel('Количество событий');
In [17]:
print('Мы удалили', len(data_old)-len(data), 'наблюдения из первоначальных', len(data_old), 'наблюдений.')
print('Это составляет', round( 100*(len(data_old)-len(data))/len(data_old), 2), '% от первоначальных данных.')

print('-----')

print('Мы удалили', 
      data_old['user_id'].nunique() - data['user_id'].nunique(), 
      'пользователей из первоначальных', 
      data_old['user_id'].nunique(), 
      'пользователей.')
print('Это составляет', 
      round( ( ( data_old['user_id'].nunique() - data['user_id'].nunique() ) / data_old['user_id'].nunique() ) * 100, 2), 
      '% от первоначальных данных.')
Мы удалили 2826 наблюдения из первоначальных 243713 наблюдений.
Это составляет 1.16 % от первоначальных данных.
-----
Мы удалили 17 пользователей из первоначальных 7551 пользователей.
Это составляет 0.23 % от первоначальных данных.

Как можем заметить, мы потеряли менее 2% наблюдений и менее 1% уникальных пользователей, отбросив данные ранее 2019-08-01.

Вывод

Мы отбросили неполные периоды, теперь по гистограмме дате и времени у нас отсутствуют аномалии. Мы сохранили данные с 2019-08-01, но, скорее всего, эти данные на самом деле относятся к периоду недельной давности (недельный лаг связан с тем, что технически в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого — это может «перекашивать данные»).

Проверим, что у нас есть пользователи из всех трёх экспериментальных групп¶

In [18]:
pd.pivot_table(data, index='exp_id', values='user_id', aggfunc=['nunique', 'count'])
Out[18]:
nunique count
user_id user_id
exp_id
246 2484 79302
247 2513 77022
248 2537 84563

Вывод

В каждой экспериментальной группе было более 2400 уникальных пользователей, и каждая экспериментальная группа содержит более 77 тыс наблюдений.

Изучим воронку событий¶

Посмотрим, какие события есть в логах, как часто они встречаются. Отсортируем события по частоте.¶

In [19]:
data.groupby('event_name')['user_id'].count().sort_values(ascending=False).reset_index()
Out[19]:
event_name user_id
0 MainScreenAppear 117328
1 OffersScreenAppear 46333
2 CartScreenAppear 42303
3 PaymentScreenSuccessful 33918
4 Tutorial 1005

Вывод

В логах имеется 5 событий, чаще всего встречается событие MainScreenAppear.

Посчитаем, сколько пользователей совершали каждое из этих событий. Отсортируем события по числу пользователей. Посчитаем долю пользователей, которые хоть раз совершали событие.¶

In [20]:
event_table = data.groupby('event_name')['user_id'].nunique().sort_values(ascending=False).reset_index()
event_table.columns = ['event_name', 'user_nunique']
event_table['user_share'] = event_table['user_nunique'] / data['user_id'].nunique()
event_table
Out[20]:
event_name user_nunique user_share
0 MainScreenAppear 7419 0.984736
1 OffersScreenAppear 4593 0.609636
2 CartScreenAppear 3734 0.495620
3 PaymentScreenSuccessful 3539 0.469737
4 Tutorial 840 0.111495

Вывод

Среди 5 событий больше всего уникальных пользователей совершали событие MainScreenAppear (7419 событий, или 98.4% всех пользователей).

Предположим, в каком порядке происходят события

Скорее всего, события происходят в таком порядке:

  • Tutorial (руководство) - не все пользователи его читают, поэтому доля пользователей всего 11%
  • MainScreenAppear (появляется главный экран) - через этот экран прошли 98% пользователей
  • OffersScreenAppear (появляется экран с предложениями) - через этот экран прошли 60.9% пользователей
  • CartScreenAppear (появляется экран с корзиной) - через этот экран прошли 49.6% пользователей
  • PaymentScreenSuccessful (экран оплата прошла успешно) - через этот экран прошли 47% пользователей

Все события выстраиваются в последовательную цепочку.

По воронке событий посчитаем, какая доля пользователей проходит на следующий шаг воронки (от числа пользователей на предыдущем).¶

Скорее всего, экран Tutorial не является обязательным для попадания на следующий экран, поэтому при расчете воронки не будем использовать это событие.

In [21]:
# рассчитываем долю пользователей
event_table = event_table.assign(user_percent = lambda x: (x['user_nunique'] / x['user_nunique'].shift(fill_value=7419))*100)

# округлим выводимые данные до сотых
event_table['user_share'] = event_table['user_share'].round(2)
event_table['user_percent'] = event_table['user_percent'].round(2)

# удаляем строку с событием 'Tutorial' и выводим на экран
event_table.drop(index=4)
Out[21]:
event_name user_nunique user_share user_percent
0 MainScreenAppear 7419 0.98 100.00
1 OffersScreenAppear 4593 0.61 61.91
2 CartScreenAppear 3734 0.50 81.30
3 PaymentScreenSuccessful 3539 0.47 94.78

Визуализируем воронку.

In [22]:
fig = px.funnel(event_table, x='user_nunique', y='event_name')
fig.update_layout(title='Воронка продаж')
fig.update_yaxes(title=None)
fig.show()
In [23]:
print('Из экрана MainScreenAppear на следующий экран OffersScreenAppear не перешли', event_table.loc[0, 'user_percent'] - event_table.loc[1, 'user_percent'], '% пользователей.')
Из экрана MainScreenAppear на следующий экран OffersScreenAppear не перешли 38.09 % пользователей.
In [24]:
print('От первого события до оплаты доходит', round(
    (min(event_table.drop(index=4)['user_nunique']) / max(event_table.drop(index=4)['user_nunique']) * 100), 2), \
      '% пользователей')
От первого события до оплаты доходит 47.7 % пользователей

Вывод

  • Больше всего пользователей теряется на шаге MainScreenAppear - из этого экрана на следующий экран OffersScreenAppear не перешли 38.09% пользователей.

Изучим результаты эксперимента¶

Посмотрим, сколько пользователей в каждой экспериментальной группе¶

In [25]:
exp_table = data.groupby('exp_id')['user_id'].nunique().sort_values(ascending=False).reset_index()
exp_table.columns = ['exp_id', 'user_nunique']
exp_table
Out[25]:
exp_id user_nunique
0 248 2537
1 247 2513
2 246 2484

Вывод

  • Пользователи распределены равномерно по всем экспериментальным группам: в каждой из них 2400-2500 уникальных пользователей.

Проверим, находят ли статистические критерии разницу между выборками 246 и 247 (А/А-эксперимент)¶

Для проверки разницы между выборками 246 и 247 (А/А-эксперимент) будем использовать z-критерий:

  • выберем событие
  • посчитаем число пользователей, совершивших это событие в каждой из контрольных групп
  • посчитаем долю пользователей, совершивших это событие
  • проверим, будет ли отличие между группами статистически достоверным
  • проделаем то же самое для всех событий.

Сформулируем гипотезы:

Нулевая гипотеза: в проверяемых группах отсутствуют различия в доле пользователей, совершивших выбранное событие

Альтернативная гипотеза: в проверяемых группах отличается доля пользователей, совершивших выбранное событие

Гипотезы проверием с помощью z-критерия. Пропишем универсальную функцию check_hypothesis для проведения теста с использованием z-критерия.

In [26]:
# Зададим универсальную функцию для проведения теста с использованием z-критерия

def check_hypothesis(successes1, successes2, trials1, trials2, alpha=0.01):
    # пропорция успехов в первой группе
    p1 = successes1/trials1
 
    # пропорция успехов во второй группе
    p2 = successes2/trials2
 
    # пропорция успехов в комбинированном датасете
    p_combined = (successes1 + successes2) / (trials1 + trials2)
 
    # разница пропорций в датасетах
    difference = p1 - p2
    
    # считаем статистику в ст.отклонениях стандартного нормального распределения
    z_value = difference / np.sqrt(p_combined * (1 - p_combined) * (1/trials1 + 1/trials2))
 
    # задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
    distr = stats.norm(0, 1) 
 
    p_value = (1 - distr.cdf(abs(z_value))) * 2
 
    print('P-значение:', round(p_value, 2))
    print('Уровень значимости:', alpha)
 
    if (p_value < alpha): print("Отвергаем нулевую гипотезу: между долями есть значимая разница.")
    else: print("Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.")

Пропишем цикл для расчета z-критерия для всех событий из таблицы event_table. Проанализируем результаты.

In [27]:
# задаем цикл
for event in event_table['event_name']:
    
    # фильтруем по event 
    data_event = data.query('event_name == @event')
    
    # группируем по event
    exp_event = data_event.groupby('exp_id')['user_id'].nunique().sort_values(ascending=False)
    
    # показываем выбранный event на экране
    print(event)
    
    # применяем функцию check_hypothesis для расчета z-критерия
    check_hypothesis(
    successes1 = exp_event.loc[246],
    successes2 = exp_event.loc[247],
    trials1 = data.query('exp_id == 246')['user_id'].nunique(),
    trials2 = data.query('exp_id == 247')['user_id'].nunique(), 
    alpha=0.01)
    print('----')
MainScreenAppear
P-значение: 0.76
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----
OffersScreenAppear
P-значение: 0.25
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----
CartScreenAppear
P-значение: 0.23
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----
PaymentScreenSuccessful
P-значение: 0.11
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----
Tutorial
P-значение: 0.94
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----

Вывод

  • для каждого события нулевая гипотеза об идентичности её доли в группах 246 и 247 не отвергается, следовательно, разбиение на группы 246 и 247 (А/А) работает корректно

Проверим, есть ли статистические разницы между контрольными группами и группой с изменённым шрифтом. Сравним результаты с каждой из контрольных групп в отдельности по каждому событию. Сравним результаты с объединённой контрольной группой.¶

Пропишем цикл для расчета z-критерия для всех событий из таблицы event_table для группы 246 и 248.

In [28]:
# задаем цикл
for event in event_table['event_name']:
    
    # фильтруем по event 
    data_event = data.query('event_name == @event')
    
    # группируем по event
    exp_event = data_event.groupby('exp_id')['user_id'].nunique().sort_values(ascending=False)
    
    # показываем выбранный event на экране
    print(event)
    
    # применяем функцию check_hypothesis для расчета z-критерия
    check_hypothesis(
    successes1 = exp_event.loc[246],
    successes2 = exp_event.loc[248],
    trials1 = data.query('exp_id == 246')['user_id'].nunique(),
    trials2 = data.query('exp_id == 248')['user_id'].nunique(), 
    alpha=0.01)
    print('----')
MainScreenAppear
P-значение: 0.29
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----
OffersScreenAppear
P-значение: 0.21
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----
CartScreenAppear
P-значение: 0.08
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----
PaymentScreenSuccessful
P-значение: 0.21
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----
Tutorial
P-значение: 0.83
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----

Пропишем цикл для расчета z-критерия для всех событий из таблицы event_table для группы 247 и 248.

In [29]:
# задаем цикл
for event in event_table['event_name']:
    
    # фильтруем по event 
    data_event = data.query('event_name == @event')
    
    # группируем по event
    exp_event = data_event.groupby('exp_id')['user_id'].nunique().sort_values(ascending=False)
    
    # показываем выбранный event на экране
    print(event)
    
    # применяем функцию check_hypothesis для расчета z-критерия
    check_hypothesis(
    successes1 = exp_event.loc[247],
    successes2 = exp_event.loc[248],
    trials1 = data.query('exp_id == 247')['user_id'].nunique(),
    trials2 = data.query('exp_id == 248')['user_id'].nunique(), 
    alpha=0.01)
    print('----')
MainScreenAppear
P-значение: 0.46
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----
OffersScreenAppear
P-значение: 0.92
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----
CartScreenAppear
P-значение: 0.58
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----
PaymentScreenSuccessful
P-значение: 0.74
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----
Tutorial
P-значение: 0.77
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----

Пропишем цикл для расчета z-критерия для всех событий из таблицы event_table для группы 246+247 и 248.

In [30]:
# задаем цикл
for event in event_table['event_name']:
    
    # фильтруем по event 
    data_event = data.query('event_name == @event')
    
    # группируем по event
    exp_event = data_event.groupby('exp_id')['user_id'].nunique().sort_values(ascending=False)
    
    # показываем выбранный event на экране
    print(event)
    
    # применяем функцию check_hypothesis для расчета z-критерия
    check_hypothesis(
    successes1 = exp_event.loc[246] + exp_event.loc[247],
    successes2 = exp_event.loc[248],
    trials1 = data.query('exp_id == 246 or exp_id == 247')['user_id'].nunique(),
    trials2 = data.query('exp_id == 248')['user_id'].nunique(), 
    alpha=0.01)
    print('----')
MainScreenAppear
P-значение: 0.29
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----
OffersScreenAppear
P-значение: 0.43
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----
CartScreenAppear
P-значение: 0.18
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----
PaymentScreenSuccessful
P-значение: 0.6
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----
Tutorial
P-значение: 0.76
Уровень значимости: 0.01
Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.
----

Вывод

  • для каждого события нулевая гипотеза об идентичности её доли в группах 246 vs 248, 247 vs 248, 246+247 vs 248 не отвергается, следовательно, между контрольными группами и группой с изменённым шрифтом отсутствуют статистические различия на уровне значимости 1%

Примечание

  • мы сделали 20 попарных сравнений (5 событий по 4 сравнения) для проверки одной нулевой гипотезы, что между группами А/А/B нет значимых различий

  • чем больше сравнений подгрупп в тесте, тем тем выше вероятность получения хотя бы одного ложноположительного результата (ошибка первого рода) - это когда по результату статистического теста отвергнута верная нулевая гипотеза

  • когда используется уровень значимости 1%, то мы соглашаемся с тем, что в 1% случаев мы будем ошибаться

  • в нашем случае, используя 1% уровень значимости, мы могли совершенно случайно получить ложное заключение в 1 из 100 тестов

  • так как мы использовали 20 попарных сравнений подгрупп, вероятность получить ложноположительный результат возрастает до 18,2% (1 - 0,99^20)

  • стоит отметить, что во всех наших тестах для подгрупп p-value был намного выше 1%, и ни разу нулевая гипотеза не была отвергнута, следовательно, необходимости в корректировке выбранного уровня значимости нет

Общий вывод¶

В рамках этого исследования мы выявили следующее

  1. Больше всего пользователей теряется на главном экране приложения MainScreenAppear - из этого экрана на экран с предложениями OffersScreenAppear не перешли 38% пользователей. От главного экрана MainScreenAppear до успешного совершения оплаты продукта доходят 47.7% пользователей.

  2. Первый экран с руководством Tutorial читает всего 11% пользователей.

  3. Результаты А/А/В-эксперимента показали отсутствие статистических различий на 1% уровне значимости между контрольными группами и группой с изменённым шрифтом.

Следовательно, для повышения эффективности продаж в мобильном приложении можно улучшить главный экран MainScreenAppear. Менять шрифты в приложении смысла нет, так как в поведении пользователей не было выявлено статистически значимых отличий.